'use strict';
console.clear();
// This is a prime example of what starts out as a simple project
// and snowballs way beyond its intended size. It's a little clunky
// reading/working on this single file, but here it is anyways :)

const IS_MOBILE = window.innerWidth <= 640;
const IS_DESKTOP = window.innerWidth > 800;
const IS_HEADER = IS_DESKTOP && window.innerHeight < 300;
// Detect high end devices. This will be a moving target.
const IS_HIGH_END_DEVICE = (() => {
    const hwConcurrency = navigator.hardwareConcurrency;
    if (!hwConcurrency) {
        return false;
    }
    // Large screens indicate a full size computer, which often have hyper threading these days.
    // So a quad core desktop machine has 8 cores. We'll place a higher min threshold there.
    const minCount = window.innerWidth <= 1024 ? 4 : 8;
    return hwConcurrency >= minCount;
})();
// Prevent canvases from getting too large on ridiculous screen sizes.
// 8K - can restrict this if needed
const MAX_WIDTH = 7680;
const MAX_HEIGHT = 4320;
const GRAVITY = 0.9; // Acceleration in px/s
let simSpeed = 1;

function getDefaultScaleFactor() {
    if (IS_MOBILE) return 0.9;
    if (IS_HEADER) return 0.75;
    return 1;
}

// Width/height values that take scale into account.
// USE THESE FOR DRAWING POSITIONS
let stageW, stageH;

// All quality globals will be overwritten and updated via `configDidUpdate`.
let quality = 1;
let isLowQuality = false;
let isNormalQuality = true;
let isHighQuality = false;

const QUALITY_LOW = 1;
const QUALITY_NORMAL = 2;
const QUALITY_HIGH = 3;

const SKY_LIGHT_NONE = 0;
const SKY_LIGHT_DIM = 1;
const SKY_LIGHT_NORMAL = 2;

const COLOR = {
    Red: '#ff0043',
    Green: '#14fc56',
    Blue: '#1e7fff',
    Purple: '#e60aff',
    Gold: '#ffbf36',
    White: '#ffffff'
};

// Special invisible color (not rendered, and therefore not in COLOR map)
const INVISIBLE = '_INVISIBLE_';

const PI_2 = Math.PI * 2;
const PI_HALF = Math.PI * 0.5;

// Stage.disableHighDPI = true;
const trailsStage = new Stage('trails-canvas');
const mainStage = new Stage('main-canvas');
const stages = [
    trailsStage,
    mainStage
];



// Fullscreen helpers, using Fscreen for prefixes.
function fullscreenEnabled() {
    return fscreen.fullscreenEnabled;
}

// Note that fullscreen state is synced to store, and the store should be the source
// of truth for whether the app is in fullscreen mode or not.
function isFullscreen() {
    return !!fscreen.fullscreenElement;
}

// Attempt to toggle fullscreen mode.
function toggleFullscreen() {
    if (fullscreenEnabled()) {
        if (isFullscreen()) {
            fscreen.exitFullscreen();
        } else {
            fscreen.requestFullscreen(document.documentElement);
        }
    }
}

// Sync fullscreen changes with store. An event listener is necessary because the user can
// toggle fullscreen mode directly through the browser, and we want to react to that.
fscreen.addEventListener('fullscreenchange', () => {
    store.setState({
        fullscreen: isFullscreen()
    });
});




// Simple state container; the source of truth.
const store = {
    _listeners: new Set(),
    _dispatch(prevState) {
        this._listeners.forEach(listener => listener(this.state, prevState))
    },

    state: {
        // will be unpaused in init()
        paused: true,
        soundEnabled: true,
        menuOpen: false,
        openHelpTopic: null,
        fullscreen: isFullscreen(),
        // Note that config values used for <select>s must be strings, unless manually converting values to strings
        // at render time, and parsing on change.
        config: {
            quality: String(IS_HIGH_END_DEVICE ? QUALITY_HIGH : QUALITY_NORMAL), // will be mirrored to a global variable named `quality` in `configDidUpdate`, for perf.
            shell: 'Random',
            size: IS_DESKTOP ?
                '3' // Desktop default
                :
                IS_HEADER ?
                '1.2' // Profile header default (doesn't need to be an int)
                :
                '2', // Mobile default
            autoLaunch: false,
            finale: false,
            skyLighting: SKY_LIGHT_NORMAL + '',
            hideControls: IS_HEADER,
            longExposure: false,
            scaleFactor: getDefaultScaleFactor()
        }
    },

    setState(nextState) {
        const prevState = this.state;
        this.state = Object.assign({}, this.state, nextState);
        this._dispatch(prevState);
        this.persist();
    },

    subscribe(listener) {
        this._listeners.add(listener);
        return () => this._listeners.remove(listener);
    },

    // Load / persist select state to localStorage
    // Mutates state because `store.load()` should only be called once immediately after store is created, before any subscriptions.
    load() {
        const serializedData = localStorage.getItem('cm_fireworks_data');
        if (serializedData) {
            const {
                schemaVersion,
                data
            } = JSON.parse(serializedData);

            const config = this.state.config;
            switch (schemaVersion) {
                case '1.1':
                    config.quality = data.quality;
                    config.size = data.size;
                    config.skyLighting = data.skyLighting;
                    break;
                case '1.2':
                    config.quality = data.quality;
                    config.size = data.size;
                    config.skyLighting = data.skyLighting;
                    config.scaleFactor = data.scaleFactor;
                    break;
                default:
                    throw new Error('version switch should be exhaustive');
            }
            console.log(`Loaded config (schema version ${schemaVersion})`);
        }
        // Deprecated data format. Checked with care (it's not namespaced).
        else if (localStorage.getItem('schemaVersion') === '1') {
            let size;
            // Attempt to parse data, ignoring if there is an error.
            try {
                const sizeRaw = localStorage.getItem('configSize');
                size = typeof sizeRaw === 'string' && JSON.parse(sizeRaw);
            } catch (e) {
                console.log('Recovered from error parsing saved config:');
                console.error(e);
                return;
            }
            // Only restore validated values
            const sizeInt = parseInt(size, 10);
            if (sizeInt >= 0 && sizeInt <= 4) {
                this.state.config.size = String(sizeInt);
            }
        }
    },

    persist() {
        const config = this.state.config;
        localStorage.setItem('cm_fireworks_data', JSON.stringify({
            schemaVersion: '1.2',
            data: {
                quality: config.quality,
                size: config.size,
                skyLighting: config.skyLighting,
                scaleFactor: config.scaleFactor
            }
        }));
    }
};


if (!IS_HEADER) {
    store.load();
}

// Actions
// ---------

function togglePause(toggle) {
    const paused = store.state.paused;
    let newValue;
    if (typeof toggle === 'boolean') {
        newValue = toggle;
    } else {
        newValue = !paused;
    }

    if (paused !== newValue) {
        store.setState({
            paused: newValue
        });
    }
}

function toggleSound(toggle) {
    if (typeof toggle === 'boolean') {
        store.setState({
            soundEnabled: toggle
        });
    } else {
        store.setState({
            soundEnabled: !store.state.soundEnabled
        });
    }
}

function toggleMenu(toggle) {
    if (typeof toggle === 'boolean') {
        store.setState({
            menuOpen: toggle
        });
    } else {
        store.setState({
            menuOpen: !store.state.menuOpen
        });
    }
}

function updateConfig(nextConfig) {
    nextConfig = nextConfig || getConfigFromDOM();
    store.setState({
        config: Object.assign({}, store.state.config, nextConfig)
    });

    configDidUpdate();
}

// Map config to various properties & apply side effects
function configDidUpdate() {
    const config = store.state.config;

    quality = qualitySelector();
    isLowQuality = quality === QUALITY_LOW;
    isNormalQuality = quality === QUALITY_NORMAL;
    isHighQuality = quality === QUALITY_HIGH;

    if (skyLightingSelector() === SKY_LIGHT_NONE) {
        appNodes.canvasContainer.style.backgroundColor = '#000';
    }

    Spark.drawWidth = quality === QUALITY_HIGH ? 0.75 : 1;
}

// Selectors
// -----------

const isRunning = (state = store.state) => !state.paused && !state.menuOpen;
// Whether user has enabled sound.
const soundEnabledSelector = (state = store.state) => state.soundEnabled;
// Whether any sounds are allowed, taking into account multiple factors.
const canPlaySoundSelector = (state = store.state) => isRunning(state) && soundEnabledSelector(state);
// Convert quality to number.
const qualitySelector = () => +store.state.config.quality;
const shellNameSelector = () => store.state.config.shell;
// Convert shell size to number.
const shellSizeSelector = () => +store.state.config.size;
const finaleSelector = () => store.state.config.finale;
const skyLightingSelector = () => +store.state.config.skyLighting;
const scaleFactorSelector = () => store.state.config.scaleFactor;



// Help Content
const helpContent = {
    shellType: {
        header: '烟花类型',
        body: '选择将要发射的烟花类型。 选择“随机”是一个不错的选择！'
    },
    shellSize: {
        header: '烟花大小',
        body: '烟花的大小：以真实的烟花炮弹尺寸为模型，较大的炮弹具有更大的爆发力，带有更多的星星，有时效果更复杂。 但是，较大的尺寸还需要更多的处理能力，并且可能会导致延迟。'
    },
    quality: {
        header: '画质',
        body: '整体图形质量。 如果动画运行不流畅，请尝试降低质量。 高质量会大大增加产生的火花数量，并可能导致滞后。'
    },
    skyLighting: {
        header: '天空照明',
        body: '烟花爆炸时照亮背景。 如果背景在屏幕上看起来太亮，请尝试将其设置为“暗淡”或“无”。'
    },
    scaleFactor: {
        header: '规模',
        body: '允许缩放所有烟花的大小，实质上使您靠近或远离。 对于较大的烟花类型，可以稍微减小比例，尤其是在手机或平板电脑上。'
    },
    autoLaunch: {
        header: '自动发射',
        body: '启用此选项即可自动启动烟花序列，取消勾选你就可以手动控制烟花的发射'
    },
    finaleMode: {
        header: '结局模式',
        body: '发射强烈的烟花。 可能会导致延迟。 需要同时启用“自动发射”。'
    },
    hideControls: {
        header: '隐藏控制器',
        body: '隐藏屏幕顶部的半透明控件。 对于屏幕截图很有用，或者只是更无缝的体验。 隐藏后，您仍然可以点击右上角以重新打开此菜单。'
    },
    fullscreen: {
        header: '全屏',
        body: '切换全屏模式。'
    },
    longExposure: {
        header: '打开快门',
        body: '实验效果可以保留长时间的光线，类似于保持相机快门打开。'
    }
};

const nodeKeyToHelpKey = {
    shellTypeLabel: 'shellType',
    shellSizeLabel: 'shellSize',
    qualityLabel: 'quality',
    skyLightingLabel: 'skyLighting',
    scaleFactorLabel: 'scaleFactor',
    autoLaunchLabel: 'autoLaunch',
    finaleModeLabel: 'finaleMode',
    hideControlsLabel: 'hideControls',
    fullscreenLabel: 'fullscreen',
    longExposureLabel: 'longExposure'
};


// Render app UI / keep in sync with state
const appNodes = {
    stageContainer: '.stage-container',
    canvasContainer: '.canvas-container',
    controls: '.controls',
    menu: '.menu',
    menuInnerWrap: '.menu__inner-wrap',
    pauseBtn: '.pause-btn',
    pauseBtnSVG: '.pause-btn use',
    soundBtn: '.sound-btn',
    soundBtnSVG: '.sound-btn use',
    shellType: '.shell-type',
    shellTypeLabel: '.shell-type-label',
    shellSize: '.shell-size',
    shellSizeLabel: '.shell-size-label',
    quality: '.quality-ui',
    qualityLabel: '.quality-ui-label',
    skyLighting: '.sky-lighting',
    skyLightingLabel: '.sky-lighting-label',
    scaleFactor: '.scaleFactor',
    scaleFactorLabel: '.scaleFactor-label',
    autoLaunch: '.auto-launch',
    autoLaunchLabel: '.auto-launch-label',
    finaleModeFormOption: '.form-option--finale-mode',
    finaleMode: '.finale-mode',
    finaleModeLabel: '.finale-mode-label',
    hideControls: '.hide-controls',
    hideControlsLabel: '.hide-controls-label',
    fullscreenFormOption: '.form-option--fullscreen',
    fullscreen: '.fullscreen',
    fullscreenLabel: '.fullscreen-label',
    longExposure: '.long-exposure',
    longExposureLabel: '.long-exposure-label',

    // Help UI
    helpModal: '.help-modal',
    helpModalOverlay: '.help-modal__overlay',
    helpModalHeader: '.help-modal__header',
    helpModalBody: '.help-modal__body',
    helpModalCloseBtn: '.help-modal__close-btn'
};

// Convert appNodes selectors to dom nodes
Object.keys(appNodes).forEach(key => {
    appNodes[key] = document.querySelector(appNodes[key]);
});

// Remove fullscreen control if not supported.
if (!fullscreenEnabled()) {
    appNodes.fullscreenFormOption.classList.add('remove');
}

// First render is called in init()
function renderApp(state) {
    const pauseBtnIcon = `#icon-${state.paused ? 'play' : 'pause'}`;
    const soundBtnIcon = `#icon-sound-${soundEnabledSelector() ? 'on' : 'off'}`;
    /*
    appNodes.pauseBtnSVG.setAttribute('href', pauseBtnIcon);
    appNodes.pauseBtnSVG.setAttribute('xlink:href', pauseBtnIcon);
    appNodes.soundBtnSVG.setAttribute('href', soundBtnIcon);
    appNodes.soundBtnSVG.setAttribute('xlink:href', soundBtnIcon);
    appNodes.controls.classList.toggle('hide', state.menuOpen || state.config.hideControls);
    appNodes.canvasContainer.classList.toggle('blur', state.menuOpen);
    appNodes.menu.classList.toggle('hide', !state.menuOpen);
    appNodes.finaleModeFormOption.style.opacity = state.config.autoLaunch ? 1 : 0.32;
    */
    appNodes.quality.value = state.config.quality;
    appNodes.shellType.value = state.config.shell;
    appNodes.shellSize.value = state.config.size;
    appNodes.autoLaunch.checked = state.config.autoLaunch;
    appNodes.finaleMode.checked = state.config.finale;
    appNodes.skyLighting.value = state.config.skyLighting;
    appNodes.hideControls.checked = state.config.hideControls;
    appNodes.fullscreen.checked = state.fullscreen;
    appNodes.longExposure.checked = state.config.longExposure;
    appNodes.scaleFactor.value = state.config.scaleFactor.toFixed(2);

    appNodes.menuInnerWrap.style.opacity = state.openHelpTopic ? 0.12 : 1;
    appNodes.helpModal.classList.toggle('active', !!state.openHelpTopic);
    if (state.openHelpTopic) {
        const {
            header,
            body
        } = helpContent[state.openHelpTopic];
        appNodes.helpModalHeader.textContent = header;
        appNodes.helpModalBody.textContent = body;
    }
}

store.subscribe(renderApp);

// Perform side effects on state changes
function handleStateChange(state, prevState) {
    const canPlaySound = canPlaySoundSelector(state);
    const canPlaySoundPrev = canPlaySoundSelector(prevState);

    if (canPlaySound !== canPlaySoundPrev) {
        if (canPlaySound) {
            soundManager.resumeAll();
        } else {
            soundManager.pauseAll();
        }
    }
}

store.subscribe(handleStateChange);


function getConfigFromDOM() {
    return {
        quality: appNodes.quality.value,
        shell: appNodes.shellType.value,
        size: appNodes.shellSize.value,
        autoLaunch: appNodes.autoLaunch.checked,
        finale: appNodes.finaleMode.checked,
        skyLighting: appNodes.skyLighting.value,
        longExposure: appNodes.longExposure.checked,
        hideControls: appNodes.hideControls.checked,
        // Store value as number.
        scaleFactor: parseFloat(appNodes.scaleFactor.value)
    };
};

const updateConfigNoEvent = () => updateConfig();
appNodes.quality.addEventListener('input', updateConfigNoEvent);
appNodes.shellType.addEventListener('input', updateConfigNoEvent);
appNodes.shellSize.addEventListener('input', updateConfigNoEvent);
appNodes.autoLaunch.addEventListener('click', () => setTimeout(updateConfig, 0));
appNodes.finaleMode.addEventListener('click', () => setTimeout(updateConfig, 0));
appNodes.skyLighting.addEventListener('input', updateConfigNoEvent);
appNodes.longExposure.addEventListener('click', () => setTimeout(updateConfig, 0));
appNodes.hideControls.addEventListener('click', () => setTimeout(updateConfig, 0));
appNodes.fullscreen.addEventListener('click', () => setTimeout(toggleFullscreen, 0));
// Changing scaleFactor requires triggering resize handling code as well.
appNodes.scaleFactor.addEventListener('input', () => {
    updateConfig();
    handleResize();
});

Object.keys(nodeKeyToHelpKey).forEach(nodeKey => {
    const helpKey = nodeKeyToHelpKey[nodeKey];
    appNodes[nodeKey].addEventListener('click', () => {
        store.setState({
            openHelpTopic: helpKey
        });
    });
});

appNodes.helpModalCloseBtn.addEventListener('click', () => {
    store.setState({
        openHelpTopic: null
    });
});

appNodes.helpModalOverlay.addEventListener('click', () => {
    store.setState({
        openHelpTopic: null
    });
});



// Constant derivations
const COLOR_NAMES = Object.keys(COLOR);
const COLOR_CODES = COLOR_NAMES.map(colorName => COLOR[colorName]);
// Invisible stars need an indentifier, even through they won't be rendered - physics still apply.
const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE];
// Map of color codes to their index in the array. Useful for quickly determining if a color has already been updated in a loop.
const COLOR_CODE_INDEXES = COLOR_CODES_W_INVIS.reduce((obj, code, i) => {
    obj[code] = i;
    return obj;
}, {});
// Tuples is a map keys by color codes (hex) with values of { r, g, b } tuples (still just objects).
const COLOR_TUPLES = {};
COLOR_CODES.forEach(hex => {
    COLOR_TUPLES[hex] = {
        r: parseInt(hex.substr(1, 2), 16),
        g: parseInt(hex.substr(3, 2), 16),
        b: parseInt(hex.substr(5, 2), 16),
    };
});

// Get a random color.
function randomColorSimple() {
    return COLOR_CODES[Math.random() * COLOR_CODES.length | 0];
}

// Get a random color, with some customization options available.
let lastColor;

function randomColor(options) {
    const notSame = options && options.notSame;
    const notColor = options && options.notColor;
    const limitWhite = options && options.limitWhite;
    let color = randomColorSimple();

    // limit the amount of white chosen randomly
    if (limitWhite && color === COLOR.White && Math.random() < 0.6) {
        color = randomColorSimple();
    }

    if (notSame) {
        while (color === lastColor) {
            color = randomColorSimple();
        }
    } else if (notColor) {
        while (color === notColor) {
            color = randomColorSimple();
        }
    }

    lastColor = color;
    return color;
}

function whiteOrGold() {
    return Math.random() < 0.5 ? COLOR.Gold : COLOR.White;
}


// Shell helpers
function makePistilColor(shellColor) {
    return (shellColor === COLOR.White || shellColor === COLOR.Gold) ? randomColor({
        notColor: shellColor
    }) : whiteOrGold();
}

// Unique shell types
const crysanthemumShell = (size = 1) => {
    const glitter = Math.random() < 0.25;
    const singleColor = Math.random() < 0.72;
    const color = singleColor ? randomColor({
        limitWhite: true
    }) : [randomColor(), randomColor({
        notSame: true
    })];
    const pistil = singleColor && Math.random() < 0.42;
    const pistilColor = pistil && makePistilColor(color);
    const secondColor = singleColor && (Math.random() < 0.2 || color === COLOR.White) ? pistilColor || randomColor({
        notColor: color,
        limitWhite: true
    }) : null;
    const streamers = !pistil && color !== COLOR.White && Math.random() < 0.42;
    let starDensity = glitter ? 1.1 : 1.25;
    if (isLowQuality) starDensity *= 0.8;
    if (isHighQuality) starDensity = 1.2;
    return {
        shellSize: size,
        spreadSize: 300 + size * 100,
        starLife: 900 + size * 200,
        starDensity,
        color,
        secondColor,
        glitter: glitter ? 'light' : '',
        glitterColor: whiteOrGold(),
        pistil,
        pistilColor,
        streamers
    };
};


const ghostShell = (size = 1) => {
    // Extend crysanthemum shell
    const shell = crysanthemumShell(size);
    // Ghost effect can be fast, so extend star life
    shell.starLife *= 1.5;
    // Ensure we always have a single color other than white
    let ghostColor = randomColor({
        notColor: COLOR.White
    });
    // Always use streamers, and sometimes a pistil
    shell.streamers = true;
    const pistil = Math.random() < 0.42;
    const pistilColor = pistil && makePistilColor(ghostColor);
    // Ghost effect - transition from invisible to chosen color
    shell.color = INVISIBLE;
    shell.secondColor = ghostColor;
    // We don't want glitter to be spewed by invisible stars, and we don't currently
    // have a way to transition glitter state. So we'll disable it.
    shell.glitter = '';

    return shell;
};


const strobeShell = (size = 1) => {
    const color = randomColor({
        limitWhite: true
    });
    return {
        shellSize: size,
        spreadSize: 280 + size * 92,
        starLife: 1100 + size * 200,
        starLifeVariation: 0.40,
        starDensity: 1.1,
        color,
        glitter: 'light',
        glitterColor: COLOR.White,
        strobe: true,
        strobeColor: Math.random() < 0.5 ? COLOR.White : null,
        pistil: Math.random() < 0.5,
        pistilColor: makePistilColor(color)
    };
};


const palmShell = (size = 1) => {
    const color = randomColor();
    const thick = Math.random() < 0.5;
    return {
        shellSize: size,
        color,
        spreadSize: 250 + size * 75,
        starDensity: thick ? 0.15 : 0.4,
        starLife: 1800 + size * 200,
        glitter: thick ? 'thick' : 'heavy'
    };
};

const ringShell = (size = 1) => {
    const color = randomColor();
    const pistil = Math.random() < 0.75;
    return {
        shellSize: size,
        ring: true,
        color,
        spreadSize: 300 + size * 100,
        starLife: 900 + size * 200,
        starCount: 2.2 * PI_2 * (size + 1),
        pistil,
        pistilColor: makePistilColor(color),
        glitter: !pistil ? 'light' : '',
        glitterColor: color === COLOR.Gold ? COLOR.Gold : COLOR.White,
        streamers: Math.random() < 0.3
    };
    // return Object.assign({}, defaultShell, config);
};

const crossetteShell = (size = 1) => {
    const color = randomColor({
        limitWhite: true
    });
    return {
        shellSize: size,
        spreadSize: 300 + size * 100,
        starLife: 750 + size * 160,
        starLifeVariation: 0.4,
        starDensity: 0.85,
        color,
        crossette: true,
        pistil: Math.random() < 0.5,
        pistilColor: makePistilColor(color)
    };
};

const floralShell = (size = 1) => ({
    shellSize: size,
    spreadSize: 300 + size * 120,
    starDensity: 0.12,
    starLife: 500 + size * 50,
    starLifeVariation: 0.5,
    color: Math.random() < 0.65 ? 'random' : (Math.random() < 0.15 ? randomColor() : [randomColor(), randomColor({
        notSame: true
    })]),
    floral: true
});

const fallingLeavesShell = (size = 1) => ({
    shellSize: size,
    color: INVISIBLE,
    spreadSize: 300 + size * 120,
    starDensity: 0.12,
    starLife: 500 + size * 50,
    starLifeVariation: 0.5,
    glitter: 'medium',
    glitterColor: COLOR.Gold,
    fallingLeaves: true
});

const willowShell = (size = 1) => ({
    shellSize: size,
    spreadSize: 300 + size * 100,
    starDensity: 0.6,
    starLife: 3000 + size * 300,
    glitter: 'willow',
    glitterColor: COLOR.Gold,
    color: INVISIBLE
});

const crackleShell = (size = 1) => {
    // favor gold
    const color = Math.random() < 0.75 ? COLOR.Gold : randomColor();
    return {
        shellSize: size,
        spreadSize: 380 + size * 75,
        starDensity: isLowQuality ? 0.65 : 1,
        starLife: 600 + size * 100,
        starLifeVariation: 0.32,
        glitter: 'light',
        glitterColor: COLOR.Gold,
        color,
        crackle: true,
        pistil: Math.random() < 0.65,
        pistilColor: makePistilColor(color)
    };
};

const horsetailShell = (size = 1) => {
    const color = randomColor();
    return {
        shellSize: size,
        horsetail: true,
        color,
        spreadSize: 250 + size * 38,
        starDensity: 0.9,
        starLife: 2500 + size * 300,
        glitter: 'medium',
        glitterColor: Math.random() < 0.5 ? whiteOrGold() : color,
        // Add strobe effect to white horsetails, to make them more interesting
        strobe: color === COLOR.White
    };
};

function randomShellName() {
    return Math.random() < 0.5 ? 'Crysanthemum' : shellNames[(Math.random() * (shellNames.length - 1) + 1) | 0];
}

function randomShell(size) {
    // Special selection for codepen header.
    if (IS_HEADER) return randomFastShell()(size);
    // Normal operation
    return shellTypes[randomShellName()](size);
}

function shellFromConfig(size) {
    return shellTypes[shellNameSelector()](size);
}

// Get a random shell, not including processing intensive varients
// Note this is only random when "Random" shell is selected in config.
// Also, this does not create the shell, only returns the factory function.
const fastShellBlacklist = ['Falling Leaves', 'Floral', 'Willow'];

function randomFastShell() {
    const isRandom = shellNameSelector() === 'Random';
    let shellName = isRandom ? randomShellName() : shellNameSelector();
    if (isRandom) {
        while (fastShellBlacklist.includes(shellName)) {
            shellName = randomShellName();
        }
    }
    return shellTypes[shellName];
}


const shellTypes = {
    'Random': randomShell,
    'Crackle': crackleShell,
    'Crossette': crossetteShell,
    'Crysanthemum': crysanthemumShell,
    'Falling Leaves': fallingLeavesShell,
    'Floral': floralShell,
    'Ghost': ghostShell,
    'Horse Tail': horsetailShell,
    'Palm': palmShell,
    'Ring': ringShell,
    'Strobe': strobeShell,
    'Willow': willowShell
};

const shellNames = Object.keys(shellTypes);

function init() {
    // Remove loading state
    document.querySelector('.loading-init').remove();
    appNodes.stageContainer.classList.remove('remove');

    // Populate dropdowns
    function setOptionsForSelect(node, options) {
        node.innerHTML = options.reduce((acc, opt) => acc += `<option value="${opt.value}">${opt.label}</option>`, '');
    }

    // shell type
    let options = '';
    shellNames.forEach(opt => options += `<option value="${opt}">${opt}</option>`);
    appNodes.shellType.innerHTML = options;
    // shell size
    options = '';
    ['3"', '4"', '6"', '8"', '12"', '16"'].forEach((opt, i) => options += `<option value="${i}">${opt}</option>`);
    appNodes.shellSize.innerHTML = options;

    setOptionsForSelect(appNodes.quality, [{
            label: '低',
            value: QUALITY_LOW
        },
        {
            label: '正常',
            value: QUALITY_NORMAL
        },
        {
            label: '高',
            value: QUALITY_HIGH
        }
    ]);

    setOptionsForSelect(appNodes.skyLighting, [{
            label: '无',
            value: SKY_LIGHT_NONE
        },
        {
            label: '暗淡',
            value: SKY_LIGHT_DIM
        },
        {
            label: '正常',
            value: SKY_LIGHT_NORMAL
        }
    ]);

    // 0.9 is mobile default
    setOptionsForSelect(
        appNodes.scaleFactor,
        [0.5, 0.62, 0.75, 0.9, 1.0, 1.5, 2.0]
        .map(value => ({
            value: value.toFixed(2),
            label: `${value*100}%`
        }))
    );

    // Begin simulation
    togglePause(false);

    // initial render
    renderApp(store.state);

    // Apply initial config
    configDidUpdate();
}


function fitShellPositionInBoundsH(position) {
    const edge = 0.18;
    return (1 - edge * 2) * position + edge;
}

function fitShellPositionInBoundsV(position) {
    return position * 0.75;
}

function getRandomShellPositionH() {
    return fitShellPositionInBoundsH(Math.random());
}

function getRandomShellPositionV() {
    return fitShellPositionInBoundsV(Math.random());
}

function getRandomShellSize() {
    const baseSize = shellSizeSelector();
    const maxVariance = Math.min(2.5, baseSize);
    const variance = Math.random() * maxVariance;
    const size = baseSize - variance;
    const height = maxVariance === 0 ? Math.random() : 1 - (variance / maxVariance);
    const centerOffset = Math.random() * (1 - height * 0.65) * 0.5;
    const x = Math.random() < 0.5 ? 0.5 - centerOffset : 0.5 + centerOffset;
    return {
        size,
        x: fitShellPositionInBoundsH(x),
        height: fitShellPositionInBoundsV(height)
    };
}


// Launches a shell from a user pointer event, based on state.config
function launchShellFromConfig(event) {
    const shell = new Shell(shellFromConfig(shellSizeSelector()));
    const w = mainStage.width;
    const h = mainStage.height;

    shell.launch(
        event ? event.x / w : getRandomShellPositionH(),
        event ? 1 - event.y / h : getRandomShellPositionV()
    );
}


// Sequences
// -----------

function seqRandomShell() {
    const size = getRandomShellSize();
    const shell = new Shell(shellFromConfig(size.size));
    shell.launch(size.x, size.height);

    let extraDelay = shell.starLife;
    if (shell.fallingLeaves) {
        extraDelay = 4600;
    }

    return 900 + Math.random() * 600 + extraDelay;
}

function seqRandomFastShell() {
    const shellType = randomFastShell();
    const size = getRandomShellSize();
    const shell = new Shell(shellType(size.size));
    shell.launch(size.x, size.height);

    let extraDelay = shell.starLife;

    return 900 + Math.random() * 600 + extraDelay;
}

function seqTwoRandom() {
    const size1 = getRandomShellSize();
    const size2 = getRandomShellSize();
    const shell1 = new Shell(shellFromConfig(size1.size));
    const shell2 = new Shell(shellFromConfig(size2.size));
    const leftOffset = Math.random() * 0.2 - 0.1;
    const rightOffset = Math.random() * 0.2 - 0.1;
    shell1.launch(0.3 + leftOffset, size1.height);
    setTimeout(() => {
        shell2.launch(0.7 + rightOffset, size2.height);
    }, 100);

    let extraDelay = Math.max(shell1.starLife, shell2.starLife);
    if (shell1.fallingLeaves || shell2.fallingLeaves) {
        extraDelay = 4600;
    }

    return 900 + Math.random() * 600 + extraDelay;
}

function seqTriple() {
    const shellType = randomFastShell();
    const baseSize = shellSizeSelector();
    const smallSize = Math.max(0, baseSize - 1.25);

    const offset = Math.random() * 0.08 - 0.04;
    const shell1 = new Shell(shellType(baseSize));
    shell1.launch(0.5 + offset, 0.7);

    const leftDelay = 1000 + Math.random() * 400;
    const rightDelay = 1000 + Math.random() * 400;

    setTimeout(() => {
        const offset = Math.random() * 0.08 - 0.04;
        const shell2 = new Shell(shellType(smallSize));
        shell2.launch(0.2 + offset, 0.1);
    }, leftDelay);

    setTimeout(() => {
        const offset = Math.random() * 0.08 - 0.04;
        const shell3 = new Shell(shellType(smallSize));
        shell3.launch(0.8 + offset, 0.1);
    }, rightDelay);

    return 4000;
}

function seqPyramid() {
    const barrageCountHalf = IS_DESKTOP ? 7 : 4;
    const largeSize = shellSizeSelector();
    const smallSize = Math.max(0, largeSize - 3);
    const randomMainShell = Math.random() < 0.78 ? crysanthemumShell : ringShell;
    const randomSpecialShell = randomShell;

    function launchShell(x, useSpecial) {
        const isRandom = shellNameSelector() === 'Random';
        let shellType = isRandom ?
            useSpecial ? randomSpecialShell : randomMainShell :
            shellTypes[shellNameSelector()];
        const shell = new Shell(shellType(useSpecial ? largeSize : smallSize));
        const height = x <= 0.5 ? x / 0.5 : (1 - x) / 0.5;
        shell.launch(x, useSpecial ? 0.75 : height * 0.42);
    }

    let count = 0;
    let delay = 0;
    while (count <= barrageCountHalf) {
        if (count === barrageCountHalf) {
            setTimeout(() => {
                launchShell(0.5, true);
            }, delay);
        } else {
            const offset = count / barrageCountHalf * 0.5;
            const delayOffset = Math.random() * 30 + 30;
            setTimeout(() => {
                launchShell(offset, false);
            }, delay);
            setTimeout(() => {
                launchShell(1 - offset, false);
            }, delay + delayOffset);
        }

        count++;
        delay += 200;
    }

    return 3400 + barrageCountHalf * 250;
}

function seqSmallBarrage() {
    seqSmallBarrage.lastCalled = Date.now();
    const barrageCount = IS_DESKTOP ? 11 : 5;
    const specialIndex = IS_DESKTOP ? 3 : 1;
    const shellSize = Math.max(0, shellSizeSelector() - 2);
    const randomMainShell = Math.random() < 0.78 ? crysanthemumShell : ringShell;
    const randomSpecialShell = randomFastShell();

    // (cos(x*5π+0.5π)+1)/2 is a custom wave bounded by 0 and 1 used to set varying launch heights
    function launchShell(x, useSpecial) {
        const isRandom = shellNameSelector() === 'Random';
        let shellType = isRandom ?
            useSpecial ? randomSpecialShell : randomMainShell :
            shellTypes[shellNameSelector()];
        const shell = new Shell(shellType(shellSize));
        const height = (Math.cos(x * 5 * Math.PI + PI_HALF) + 1) / 2;
        shell.launch(x, height * 0.75);
    }

    let count = 0;
    let delay = 0;
    while (count < barrageCount) {
        if (count === 0) {
            launchShell(0.5, false)
            count += 1;
        } else {
            const offset = (count + 1) / barrageCount / 2;
            const delayOffset = Math.random() * 30 + 30;
            const useSpecial = count === specialIndex;
            setTimeout(() => {
                launchShell(0.5 + offset, useSpecial);
            }, delay);
            setTimeout(() => {
                launchShell(0.5 - offset, useSpecial);
            }, delay + delayOffset);
            count += 2;
        }
        delay += 200;
    }

    return 3400 + barrageCount * 120;
}
seqSmallBarrage.cooldown = 15000;
seqSmallBarrage.lastCalled = Date.now();


const sequences = [
    seqRandomShell,
    seqTwoRandom,
    seqTriple,
    seqPyramid,
    seqSmallBarrage
];


let isFirstSeq = true;
const finaleCount = 32;
let currentFinaleCount = 0;

function startSequence() {
    if (isFirstSeq) {
        isFirstSeq = false;
        if (IS_HEADER) {
            return seqTwoRandom();
        } else {
            const shell = new Shell(crysanthemumShell(shellSizeSelector()));
            shell.launch(0.5, 0.5);
            return 2400;
        }
    }

    if (finaleSelector()) {
        seqRandomFastShell();
        if (currentFinaleCount < finaleCount) {
            currentFinaleCount++;
            return 170;
        } else {
            currentFinaleCount = 0;
            return 6000;
        }
    }

    const rand = Math.random();

    if (rand < 0.08 && Date.now() - seqSmallBarrage.lastCalled > seqSmallBarrage.cooldown) {
        return seqSmallBarrage();
    }

    if (rand < 0.1) {
        return seqPyramid();
    }

    if (rand < 0.6 && !IS_HEADER) {
        return seqRandomShell();
    } else if (rand < 0.8) {
        return seqTwoRandom();
    } else if (rand < 1) {
        return seqTriple();
    }
}


let activePointerCount = 0;
let isUpdatingSpeed = false;

function handlePointerStart(event) {
    activePointerCount++;
    const btnSize = 50;

    if (event.y < btnSize) {
        if (event.x < btnSize) {
            togglePause();
            return;
        }
        if (event.x > mainStage.width / 2 - btnSize / 2 && event.x < mainStage.width / 2 + btnSize / 2) {
            toggleSound();
            return;
        }
        if (event.x > mainStage.width - btnSize) {
            toggleMenu();
            return;
        }
    }

    if (!isRunning()) return;

    if (updateSpeedFromEvent(event)) {
        isUpdatingSpeed = true;
    } else if (event.onCanvas) {
        launchShellFromConfig(event);
    }
}

function handlePointerEnd(event) {
    activePointerCount--;
    isUpdatingSpeed = false;
}

function handlePointerMove(event) {
    if (!isRunning()) return;

    if (isUpdatingSpeed) {
        updateSpeedFromEvent(event);
    }
}

function handleKeydown(event) {
    // P
    if (event.keyCode === 80) {
        togglePause();
    }
    // O
    else if (event.keyCode === 79) {
        toggleMenu();
    }
    // Esc
    else if (event.keyCode === 27) {
        toggleMenu(false);
    }
}

mainStage.addEventListener('pointerstart', handlePointerStart);
mainStage.addEventListener('pointerend', handlePointerEnd);
mainStage.addEventListener('pointermove', handlePointerMove);
window.addEventListener('keydown', handleKeydown);


// Account for window resize and custom scale changes.
function handleResize() {
    const w = window.innerWidth;
    const h = window.innerHeight;
    // Try to adopt screen size, heeding maximum sizes specified
    const containerW = Math.min(w, MAX_WIDTH);
    // On small screens, use full device height
    const containerH = w <= 420 ? h : Math.min(h, MAX_HEIGHT);
    appNodes.stageContainer.style.width = containerW + 'px';
    appNodes.stageContainer.style.height = containerH + 'px';
    stages.forEach(stage => stage.resize(containerW, containerH));
    // Account for scale
    const scaleFactor = scaleFactorSelector();
    stageW = containerW / scaleFactor;
    stageH = containerH / scaleFactor;
}

// Compute initial dimensions
handleResize();

window.addEventListener('resize', handleResize);


// Dynamic globals
let currentFrame = 0;
let speedBarOpacity = 0;
let autoLaunchTime = 0;

function updateSpeedFromEvent(event) {
    if (isUpdatingSpeed || event.y >= mainStage.height - 44) {
        // On phones it's hard to hit the edge pixels in order to set speed at 0 or 1, so some padding is provided to make that easier.
        const edge = 16;
        const newSpeed = (event.x - edge) / (mainStage.width - edge * 2);
        simSpeed = Math.min(Math.max(newSpeed, 0), 1);
        // show speed bar after an update
        speedBarOpacity = 1;
        // If we updated the speed, return true
        return true;
    }
    // Return false if the speed wasn't updated
    return false;
}


// Extracted function to keep `update()` optimized
function updateGlobals(timeStep, lag) {
    currentFrame++;

    // Always try to fade out speed bar
    if (!isUpdatingSpeed) {
        speedBarOpacity -= lag / 30; // half a second
        if (speedBarOpacity < 0) {
            speedBarOpacity = 0;
        }
    }

    // auto launch shells
    if (store.state.config.autoLaunch) {
        autoLaunchTime -= timeStep;
        if (autoLaunchTime <= 0) {
            autoLaunchTime = startSequence() * 1.25;
        }
    }
}


function update(frameTime, lag) {
    if (!isRunning()) return;

    const width = stageW;
    const height = stageH;
    const timeStep = frameTime * simSpeed;
    const speed = simSpeed * lag;

    updateGlobals(timeStep, lag);

    const starDrag = 1 - (1 - Star.airDrag) * speed;
    const starDragHeavy = 1 - (1 - Star.airDragHeavy) * speed;
    const sparkDrag = 1 - (1 - Spark.airDrag) * speed;
    const gAcc = timeStep / 1000 * GRAVITY;
    COLOR_CODES_W_INVIS.forEach(color => {
        // Stars
        const stars = Star.active[color];
        for (let i = stars.length - 1; i >= 0; i = i - 1) {
            const star = stars[i];
            // Only update each star once per frame. Since color can change, it's possible a star could update twice without this, leading to a "jump".
            if (star.updateFrame === currentFrame) {
                continue;
            }
            star.updateFrame = currentFrame;

            star.life -= timeStep;
            if (star.life <= 0) {
                stars.splice(i, 1);
                Star.returnInstance(star);
            } else {
                const burnRate = Math.pow(star.life / star.fullLife, 0.5);
                const burnRateInverse = 1 - burnRate;

                star.prevX = star.x;
                star.prevY = star.y;
                star.x += star.speedX * speed;
                star.y += star.speedY * speed;
                // Apply air drag if star isn't "heavy". The heavy property is used for the shell comets.
                if (!star.heavy) {
                    star.speedX *= starDrag;
                    star.speedY *= starDrag;
                } else {
                    star.speedX *= starDragHeavy;
                    star.speedY *= starDragHeavy;
                }
                star.speedY += gAcc;

                if (star.spinRadius) {
                    star.spinAngle += star.spinSpeed * speed;
                    star.x += Math.sin(star.spinAngle) * star.spinRadius * speed;
                    star.y += Math.cos(star.spinAngle) * star.spinRadius * speed;
                }

                if (star.sparkFreq) {
                    star.sparkTimer -= timeStep;
                    while (star.sparkTimer < 0) {
                        star.sparkTimer += star.sparkFreq * 0.75 + star.sparkFreq * burnRateInverse * 4;
                        Spark.add(
                            star.x,
                            star.y,
                            star.sparkColor,
                            Math.random() * PI_2,
                            Math.random() * star.sparkSpeed * burnRate,
                            star.sparkLife * 0.8 + Math.random() * star.sparkLifeVariation * star.sparkLife
                        );
                    }
                }

                // Handle star transitions
                if (star.life < star.transitionTime) {
                    if (star.secondColor && !star.colorChanged) {
                        star.colorChanged = true;
                        star.color = star.secondColor;
                        stars.splice(i, 1);
                        Star.active[star.secondColor].push(star);
                        if (star.secondColor === INVISIBLE) {
                            star.sparkFreq = 0;
                        }
                    }

                    if (star.strobe) {
                        // Strobes in the following pattern: on:off:off:on:off:off in increments of `strobeFreq` ms.
                        star.visible = Math.floor(star.life / star.strobeFreq) % 3 === 0;
                    }
                }
            }
        }

        // Sparks
        const sparks = Spark.active[color];
        for (let i = sparks.length - 1; i >= 0; i = i - 1) {
            const spark = sparks[i];
            spark.life -= timeStep;
            if (spark.life <= 0) {
                sparks.splice(i, 1);
                Spark.returnInstance(spark);
            } else {
                spark.prevX = spark.x;
                spark.prevY = spark.y;
                spark.x += spark.speedX * speed;
                spark.y += spark.speedY * speed;
                spark.speedX *= sparkDrag;
                spark.speedY *= sparkDrag;
                spark.speedY += gAcc;
            }
        }
    });

    render(speed);
}

function render(speed) {
    const {
        dpr
    } = mainStage;
    const width = stageW;
    const height = stageH;
    const trailsCtx = trailsStage.ctx;
    const mainCtx = mainStage.ctx;

    if (skyLightingSelector() !== SKY_LIGHT_NONE) {
        colorSky(speed);
    }

    // Account for high DPI screens, and custom scale factor.
    const scaleFactor = scaleFactorSelector();
    trailsCtx.scale(dpr * scaleFactor, dpr * scaleFactor);
    mainCtx.scale(dpr * scaleFactor, dpr * scaleFactor);

    trailsCtx.globalCompositeOperation = 'source-over';
    trailsCtx.fillStyle = `rgba(0, 0, 0, ${store.state.config.longExposure ? 0.0025 : 0.175 * speed})`;
    trailsCtx.fillRect(0, 0, width, height);

    mainCtx.clearRect(0, 0, width, height);

    // Draw queued burst flashes
    // These must also be drawn using source-over due to Safari. Seems rendering the gradients using lighten draws large black boxes instead.
    // Thankfully, these burst flashes look pretty much the same either way.
    while (BurstFlash.active.length) {
        const bf = BurstFlash.active.pop();

        const burstGradient = trailsCtx.createRadialGradient(bf.x, bf.y, 0, bf.x, bf.y, bf.radius);
        burstGradient.addColorStop(0.024, 'rgba(255, 255, 255, 1)');
        burstGradient.addColorStop(0.125, 'rgba(255, 160, 20, 0.2)');
        burstGradient.addColorStop(0.32, 'rgba(255, 140, 20, 0.11)');
        burstGradient.addColorStop(1, 'rgba(255, 120, 20, 0)');
        trailsCtx.fillStyle = burstGradient;
        trailsCtx.fillRect(bf.x - bf.radius, bf.y - bf.radius, bf.radius * 2, bf.radius * 2);

        BurstFlash.returnInstance(bf);
    }

    // Remaining drawing on trails canvas will use 'lighten' blend mode
    trailsCtx.globalCompositeOperation = 'lighten';

    // Draw stars
    trailsCtx.lineWidth = Star.drawWidth;
    trailsCtx.lineCap = isLowQuality ? 'square' : 'round';
    mainCtx.strokeStyle = '#fff';
    mainCtx.lineWidth = 1;
    mainCtx.beginPath();
    COLOR_CODES.forEach(color => {
        const stars = Star.active[color];
        trailsCtx.strokeStyle = color;
        trailsCtx.beginPath();
        stars.forEach(star => {
            if (star.visible) {
                trailsCtx.moveTo(star.x, star.y);
                trailsCtx.lineTo(star.prevX, star.prevY);
                mainCtx.moveTo(star.x, star.y);
                mainCtx.lineTo(star.x - star.speedX * 1.6, star.y - star.speedY * 1.6);
            }
        });
        trailsCtx.stroke();
    });
    mainCtx.stroke();

    // Draw sparks
    trailsCtx.lineWidth = Spark.drawWidth;
    trailsCtx.lineCap = 'butt';
    COLOR_CODES.forEach(color => {
        const sparks = Spark.active[color];
        trailsCtx.strokeStyle = color;
        trailsCtx.beginPath();
        sparks.forEach(spark => {
            trailsCtx.moveTo(spark.x, spark.y);
            trailsCtx.lineTo(spark.prevX, spark.prevY);
        });
        trailsCtx.stroke();
    });


    // Render speed bar if visible
    if (speedBarOpacity) {
        const speedBarHeight = 6;
        mainCtx.globalAlpha = speedBarOpacity;
        mainCtx.fillStyle = COLOR.Blue;
        mainCtx.fillRect(0, height - speedBarHeight, width * simSpeed, speedBarHeight);
        mainCtx.globalAlpha = 1;
    }


    trailsCtx.setTransform(1, 0, 0, 1, 0, 0);
    mainCtx.setTransform(1, 0, 0, 1, 0, 0);
}


// Draw colored overlay based on combined brightness of stars (light up the sky!)
// Note: this is applied to the canvas container's background-color, so it's behind the particles
const currentSkyColor = {
    r: 0,
    g: 0,
    b: 0
};
const targetSkyColor = {
    r: 0,
    g: 0,
    b: 0
};

function colorSky(speed) {
    // The maximum r, g, or b value that will be used (255 would represent no maximum)
    const maxSkySaturation = skyLightingSelector() * 15;
    // How many stars are required in total to reach maximum sky brightness
    const maxStarCount = 500;
    let totalStarCount = 0;
    // Initialize sky as black
    targetSkyColor.r = 0;
    targetSkyColor.g = 0;
    targetSkyColor.b = 0;
    // Add each known color to sky, multiplied by particle count of that color. This will put RGB values wildly out of bounds, but we'll scale them back later.
    // Also add up total star count.
    COLOR_CODES.forEach(color => {
        const tuple = COLOR_TUPLES[color];
        const count = Star.active[color].length;
        totalStarCount += count;
        targetSkyColor.r += tuple.r * count;
        targetSkyColor.g += tuple.g * count;
        targetSkyColor.b += tuple.b * count;
    });

    // Clamp intensity at 1.0, and map to a custom non-linear curve. This allows few stars to perceivably light up the sky, while more stars continue to increase the brightness but at a lesser rate. This is more inline with humans' non-linear brightness perception.
    const intensity = Math.pow(Math.min(1, totalStarCount / maxStarCount), 0.3);
    // Figure out which color component has the highest value, so we can scale them without affecting the ratios.
    // Prevent 0 from being used, so we don't divide by zero in the next step.
    const maxColorComponent = Math.max(1, targetSkyColor.r, targetSkyColor.g, targetSkyColor.b);
    // Scale all color components to a max of `maxSkySaturation`, and apply intensity.
    targetSkyColor.r = targetSkyColor.r / maxColorComponent * maxSkySaturation * intensity;
    targetSkyColor.g = targetSkyColor.g / maxColorComponent * maxSkySaturation * intensity;
    targetSkyColor.b = targetSkyColor.b / maxColorComponent * maxSkySaturation * intensity;

    // Animate changes to color to smooth out transitions.
    const colorChange = 10;
    currentSkyColor.r += (targetSkyColor.r - currentSkyColor.r) / colorChange * speed;
    currentSkyColor.g += (targetSkyColor.g - currentSkyColor.g) / colorChange * speed;
    currentSkyColor.b += (targetSkyColor.b - currentSkyColor.b) / colorChange * speed;

    appNodes.canvasContainer.style.backgroundColor = `rgb(${currentSkyColor.r | 0}, ${currentSkyColor.g | 0}, ${currentSkyColor.b | 0})`;
}

mainStage.addEventListener('ticker', update);


// Helper used to semi-randomly spread particles over an arc
// Values are flexible - `start` and `arcLength` can be negative, and `randomness` is simply a multiplier for random addition.
function createParticleArc(start, arcLength, count, randomness, particleFactory) {
    const angleDelta = arcLength / count;
    // Sometimes there is an extra particle at the end, too close to the start. Subtracting half the angleDelta ensures that is skipped.
    // Would be nice to fix this a better way.
    const end = start + arcLength - (angleDelta * 0.5);

    if (end > start) {
        // Optimization: `angle=angle+angleDelta` vs. angle+=angleDelta
        // V8 deoptimises with let compound assignment
        for (let angle = start; angle < end; angle = angle + angleDelta) {
            particleFactory(angle + Math.random() * angleDelta * randomness);
        }
    } else {
        for (let angle = start; angle > end; angle = angle + angleDelta) {
            particleFactory(angle + Math.random() * angleDelta * randomness);
        }
    }
}


/**
 * Helper used to create a spherical burst of particles.
 *
 * @param  {Number} count               The desired number of stars/particles. This value is a suggestion, and the
 *                                      created burst may have more particles. The current algorithm can't perfectly
 *                                      distribute a specific number of points evenly on a sphere's surface.
 * @param  {Function} particleFactory   Called once per star/particle generated. Passed two arguments:
 *                                        `angle`: The direction of the star/particle.
 *                                        `speed`: A multipler for the particle speed, from 0.0 to 1.0.
 * @param  {Number} startAngle=0        For segmented bursts, you can generate only a partial arc of particles. This
 *                                      allows setting the starting arc angle (radians).
 * @param  {Number} arcLength=TAU       The length of the arc (radians). Defaults to a full circle.
 *
 * @return {void}              Returns nothing; it's up to `particleFactory` to use the given data.
 */
function createBurst(count, particleFactory, startAngle = 0, arcLength = PI_2) {
    // Assuming sphere with surface area of `count`, calculate various
    // properties of said sphere (unit is stars).
    // Radius
    const R = 0.5 * Math.sqrt(count / Math.PI);
    // Circumference
    const C = 2 * R * Math.PI;
    // Half Circumference
    const C_HALF = C / 2;

    // Make a series of rings, sizing them as if they were spaced evenly
    // along the curved surface of a sphere.
    for (let i = 0; i <= C_HALF; i++) {
        const ringAngle = i / C_HALF * PI_HALF;
        const ringSize = Math.cos(ringAngle);
        const partsPerFullRing = C * ringSize;
        const partsPerArc = partsPerFullRing * (arcLength / PI_2);

        const angleInc = PI_2 / partsPerFullRing;
        const angleOffset = Math.random() * angleInc + startAngle;
        // Each particle needs a bit of randomness to improve appearance.
        const maxRandomAngleOffset = angleInc * 0.33;

        for (let i = 0; i < partsPerArc; i++) {
            const randomAngleOffset = Math.random() * maxRandomAngleOffset;
            let angle = angleInc * i + angleOffset + randomAngleOffset;
            particleFactory(angle, ringSize);
        }
    }
}




// Various star effects.
// These are designed to be attached to a star's `onDeath` event.

// Crossette breaks star into four same-color pieces which branch in a cross-like shape.
function crossetteEffect(star) {
    const startAngle = Math.random() * PI_HALF;
    createParticleArc(startAngle, PI_2, 4, 0.5, (angle) => {
        Star.add(
            star.x,
            star.y,
            star.color,
            angle,
            Math.random() * 0.6 + 0.75,
            600
        );
    });
}

// Flower is like a mini shell
function floralEffect(star) {
    const count = 12 + 6 * quality;
    createBurst(count, (angle, speedMult) => {
        Star.add(
            star.x,
            star.y,
            star.color,
            angle,
            speedMult * 2.4,
            1000 + Math.random() * 300,
            star.speedX,
            star.speedY
        );
    });
    // Queue burst flash render
    BurstFlash.add(star.x, star.y, 46);
    soundManager.playSound('burstSmall');
}

// Floral burst with willow stars
function fallingLeavesEffect(star) {
    createBurst(7, (angle, speedMult) => {
        const newStar = Star.add(
            star.x,
            star.y,
            INVISIBLE,
            angle,
            speedMult * 2.4,
            2400 + Math.random() * 600,
            star.speedX,
            star.speedY
        );

        newStar.sparkColor = COLOR.Gold;
        newStar.sparkFreq = 144 / quality;
        newStar.sparkSpeed = 0.28;
        newStar.sparkLife = 750;
        newStar.sparkLifeVariation = 3.2;
    });
    // Queue burst flash render
    BurstFlash.add(star.x, star.y, 46);
    soundManager.playSound('burstSmall');
}

// Crackle pops into a small cloud of golden sparks.
function crackleEffect(star) {
    const count = isHighQuality ? 32 : 16;
    createParticleArc(0, PI_2, count, 1.8, (angle) => {
        Spark.add(
            star.x,
            star.y,
            COLOR.Gold,
            angle,
            // apply near cubic falloff to speed (places more particles towards outside)
            Math.pow(Math.random(), 0.45) * 2.4,
            300 + Math.random() * 200
        );
    });
}



/**
 * Shell can be constructed with options:
 *
 * spreadSize:      Size of the burst.
 * starCount: Number of stars to create. This is optional, and will be set to a reasonable quantity for size if omitted.
 * starLife:
 * starLifeVariation:
 * color:
 * glitterColor:
 * glitter: One of: 'light', 'medium', 'heavy', 'streamer', 'willow'
 * pistil:
 * pistilColor:
 * streamers:
 * crossette:
 * floral:
 * crackle:
 */
class Shell {
    constructor(options) {
        Object.assign(this, options);
        this.starLifeVariation = options.starLifeVariation || 0.125;
        this.color = options.color || randomColor();
        this.glitterColor = options.glitterColor || this.color;

        // Set default starCount if needed, will be based on shell size and scale exponentially, like a sphere's surface area.
        if (!this.starCount) {
            const density = options.starDensity || 1;
            const scaledSize = this.spreadSize / 54;
            this.starCount = Math.max(6, scaledSize * scaledSize * density);
        }
    }

    launch(position, launchHeight) {
        const width = stageW;
        const height = stageH;
        // Distance from sides of screen to keep shells.
        const hpad = 60;
        // Distance from top of screen to keep shell bursts.
        const vpad = 50;
        // Minimum burst height, as a percentage of stage height
        const minHeightPercent = 0.45;
        // Minimum burst height in px
        const minHeight = height - height * minHeightPercent;

        const launchX = position * (width - hpad * 2) + hpad;
        const launchY = height;
        const burstY = minHeight - (launchHeight * (minHeight - vpad));

        const launchDistance = launchY - burstY;
        // Using a custom power curve to approximate Vi needed to reach launchDistance under gravity and air drag.
        // Magic numbers came from testing.
        const launchVelocity = Math.pow(launchDistance * 0.04, 0.64);

        const comet = this.comet = Star.add(
            launchX,
            launchY,
            typeof this.color === 'string' && this.color !== 'random' ? this.color : COLOR.White,
            Math.PI,
            launchVelocity * (this.horsetail ? 1.2 : 1),
            // Hang time is derived linearly from Vi; exact number came from testing
            launchVelocity * (this.horsetail ? 100 : 400)
        );

        // making comet "heavy" limits air drag
        comet.heavy = true;
        // comet spark trail
        comet.spinRadius = MyMath.random(0.32, 0.85);
        comet.sparkFreq = 32 / quality;
        if (isHighQuality) comet.sparkFreq = 8;
        comet.sparkLife = 320;
        comet.sparkLifeVariation = 3;
        if (this.glitter === 'willow' || this.fallingLeaves) {
            comet.sparkFreq = 20 / quality;
            comet.sparkSpeed = 0.5;
            comet.sparkLife = 500;
        }
        if (this.color === INVISIBLE) {
            comet.sparkColor = COLOR.Gold;
        }

        // Randomly make comet "burn out" a bit early.
        // This is disabled for horsetail shells, due to their very short airtime.
        if (Math.random() > 0.4 && !this.horsetail) {
            comet.secondColor = INVISIBLE;
            comet.transitionTime = Math.pow(Math.random(), 1.5) * 700 + 500;
        }

        comet.onDeath = comet => this.burst(comet.x, comet.y);

        soundManager.playSound('lift');
    }

    burst(x, y) {
        // Set burst speed so overall burst grows to set size. This specific formula was derived from testing, and is affected by simulated air drag.
        const speed = this.spreadSize / 96;

        let color, onDeath, sparkFreq, sparkSpeed, sparkLife;
        let sparkLifeVariation = 0.25;
        // Some death effects, like crackle, play a sound, but should only be played once.
        let playedDeathSound = false;

        if (this.crossette) onDeath = (star) => {
            if (!playedDeathSound) {
                soundManager.playSound('crackleSmall');
                playedDeathSound = true;
            }
            crossetteEffect(star);
        }
        if (this.crackle) onDeath = (star) => {
            if (!playedDeathSound) {
                soundManager.playSound('crackle');
                playedDeathSound = true;
            }
            crackleEffect(star);
        }
        if (this.floral) onDeath = floralEffect;
        if (this.fallingLeaves) onDeath = fallingLeavesEffect;

        if (this.glitter === 'light') {
            sparkFreq = 400;
            sparkSpeed = 0.3;
            sparkLife = 300;
            sparkLifeVariation = 2;
        } else if (this.glitter === 'medium') {
            sparkFreq = 200;
            sparkSpeed = 0.44;
            sparkLife = 700;
            sparkLifeVariation = 2;
        } else if (this.glitter === 'heavy') {
            sparkFreq = 80;
            sparkSpeed = 0.8;
            sparkLife = 1400;
            sparkLifeVariation = 2;
        } else if (this.glitter === 'thick') {
            sparkFreq = 16;
            sparkSpeed = isHighQuality ? 1.65 : 1.5;
            sparkLife = 1400;
            sparkLifeVariation = 3;
        } else if (this.glitter === 'streamer') {
            sparkFreq = 32;
            sparkSpeed = 1.05;
            sparkLife = 620;
            sparkLifeVariation = 2;
        } else if (this.glitter === 'willow') {
            sparkFreq = 120;
            sparkSpeed = 0.34;
            sparkLife = 1400;
            sparkLifeVariation = 3.8;
        }

        // Apply quality to spark count
        sparkFreq = sparkFreq / quality;

        // Star factory for primary burst, pistils, and streamers.
        let firstStar = true;
        const starFactory = (angle, speedMult) => {
            // For non-horsetail shells, compute an initial vertical speed to add to star burst.
            // The magic number comes from testing what looks best. The ideal is that all shell
            // bursts appear visually centered for the majority of the star life (excl. willows etc.)
            const standardInitialSpeed = this.spreadSize / 1800;

            const star = Star.add(
                x,
                y,
                color || randomColor(),
                angle,
                speedMult * speed,
                // add minor variation to star life
                this.starLife + Math.random() * this.starLife * this.starLifeVariation,
                this.horsetail ? this.comet && this.comet.speedX : 0,
                this.horsetail ? this.comet && this.comet.speedY : -standardInitialSpeed
            );

            if (this.secondColor) {
                star.transitionTime = this.starLife * (Math.random() * 0.05 + 0.32);
                star.secondColor = this.secondColor;
            }

            if (this.strobe) {
                star.transitionTime = this.starLife * (Math.random() * 0.08 + 0.46);
                star.strobe = true;
                // How many milliseconds between switch of strobe state "tick". Note that the strobe pattern
                // is on:off:off, so this is the "on" duration, while the "off" duration is twice as long.
                star.strobeFreq = Math.random() * 20 + 40;
                if (this.strobeColor) {
                    star.secondColor = this.strobeColor;
                }
            }

            star.onDeath = onDeath;

            if (this.glitter) {
                star.sparkFreq = sparkFreq;
                star.sparkSpeed = sparkSpeed;
                star.sparkLife = sparkLife;
                star.sparkLifeVariation = sparkLifeVariation;
                star.sparkColor = this.glitterColor;
                star.sparkTimer = Math.random() * star.sparkFreq;
            }
        };


        if (typeof this.color === 'string') {
            if (this.color === 'random') {
                color = null; // falsey value creates random color in starFactory
            } else {
                color = this.color;
            }

            // Rings have positional randomness, but are rotated randomly
            if (this.ring) {
                const ringStartAngle = Math.random() * Math.PI;
                const ringSquash = Math.pow(Math.random(), 2) * 0.85 + 0.15;;

                createParticleArc(0, PI_2, this.starCount, 0, angle => {
                    // Create a ring, squashed horizontally
                    const initSpeedX = Math.sin(angle) * speed * ringSquash;
                    const initSpeedY = Math.cos(angle) * speed;
                    // Rotate ring
                    const newSpeed = MyMath.pointDist(0, 0, initSpeedX, initSpeedY);
                    const newAngle = MyMath.pointAngle(0, 0, initSpeedX, initSpeedY) + ringStartAngle;
                    const star = Star.add(
                        x,
                        y,
                        color,
                        newAngle,
                        // apply near cubic falloff to speed (places more particles towards outside)
                        newSpeed, //speed,
                        // add minor variation to star life
                        this.starLife + Math.random() * this.starLife * this.starLifeVariation
                    );

                    if (this.glitter) {
                        star.sparkFreq = sparkFreq;
                        star.sparkSpeed = sparkSpeed;
                        star.sparkLife = sparkLife;
                        star.sparkLifeVariation = sparkLifeVariation;
                        star.sparkColor = this.glitterColor;
                        star.sparkTimer = Math.random() * star.sparkFreq;
                    }
                });
            }
            // Normal burst
            else {
                createBurst(this.starCount, starFactory);
            }
        } else if (Array.isArray(this.color)) {
            if (Math.random() < 0.5) {
                const start = Math.random() * Math.PI;
                const start2 = start + Math.PI;
                const arc = Math.PI;
                color = this.color[0];
                // Not creating a full arc automatically reduces star count.
                createBurst(this.starCount, starFactory, start, arc);
                color = this.color[1];
                createBurst(this.starCount, starFactory, start2, arc);
            } else {
                color = this.color[0];
                createBurst(this.starCount / 2, starFactory);
                color = this.color[1];
                createBurst(this.starCount / 2, starFactory);
            }
        } else {
            throw new Error('Invalid shell color. Expected string or array of strings, but got: ' + this.color);
        }

        if (this.pistil) {
            const innerShell = new Shell({
                spreadSize: this.spreadSize * 0.5,
                starLife: this.starLife * 0.6,
                starLifeVariation: this.starLifeVariation,
                starDensity: 1.4,
                color: this.pistilColor,
                glitter: 'light',
                glitterColor: this.pistilColor === COLOR.Gold ? COLOR.Gold : COLOR.White
            });
            innerShell.burst(x, y);
        }

        if (this.streamers) {
            const innerShell = new Shell({
                spreadSize: this.spreadSize * 0.9,
                starLife: this.starLife * 0.8,
                starLifeVariation: this.starLifeVariation,
                starCount: Math.floor(Math.max(6, this.spreadSize / 45)),
                color: COLOR.White,
                glitter: 'streamer'
            });
            innerShell.burst(x, y);
        }

        // Queue burst flash render
        BurstFlash.add(x, y, this.spreadSize / 4);

        // Play sound, but only for "original" shell, the one that was launched.
        // We don't want multiple sounds from pistil or streamer "sub-shells".
        // This can be detected by the presence of a comet.
        if (this.comet) {
            // Scale explosion sound based on current shell size and selected (max) shell size.
            // Shooting selected shell size will always sound the same no matter the selected size,
            // but when smaller shells are auto-fired, they will sound smaller. It doesn't sound great
            // when a value too small is given though, so instead of basing it on proportions, we just
            // look at the difference in size and map it to a range known to sound good.
            const maxDiff = 2;
            const sizeDifferenceFromMaxSize = Math.min(maxDiff, shellSizeSelector() - this.shellSize);
            const soundScale = (1 - sizeDifferenceFromMaxSize / maxDiff) * 0.3 + 0.7;
            soundManager.playSound('burst', soundScale);
        }
    }
}



const BurstFlash = {
    active: [],
    _pool: [],

    _new() {
        return {}
    },

    add(x, y, radius) {
        const instance = this._pool.pop() || this._new();

        instance.x = x;
        instance.y = y;
        instance.radius = radius;

        this.active.push(instance);
        return instance;
    },

    returnInstance(instance) {
        this._pool.push(instance);
    }
};



// Helper to generate objects for storing active particles.
// Particles are stored in arrays keyed by color (code, not name) for improved rendering performance.
function createParticleCollection() {
    const collection = {};
    COLOR_CODES_W_INVIS.forEach(color => {
        collection[color] = [];
    });
    return collection;
}


// Star properties (WIP)
// -----------------------
// transitionTime - how close to end of life that star transition happens

const Star = {
    // Visual properties
    drawWidth: 3,
    airDrag: 0.98,
    airDragHeavy: 0.992,

    // Star particles will be keyed by color
    active: createParticleCollection(),
    _pool: [],

    _new() {
        return {};
    },

    add(x, y, color, angle, speed, life, speedOffX, speedOffY) {
        const instance = this._pool.pop() || this._new();

        instance.visible = true;
        instance.heavy = false;
        instance.x = x;
        instance.y = y;
        instance.prevX = x;
        instance.prevY = y;
        instance.color = color;
        instance.speedX = Math.sin(angle) * speed + (speedOffX || 0);
        instance.speedY = Math.cos(angle) * speed + (speedOffY || 0);
        instance.life = life;
        instance.fullLife = life;
        instance.spinAngle = Math.random() * PI_2;
        instance.spinSpeed = 0.8;
        instance.spinRadius = 0;
        instance.sparkFreq = 0; // ms between spark emissions
        instance.sparkSpeed = 1;
        instance.sparkTimer = 0;
        instance.sparkColor = color;
        instance.sparkLife = 750;
        instance.sparkLifeVariation = 0.25;
        instance.strobe = false;

        this.active[color].push(instance);
        return instance;
    },

    // Public method for cleaning up and returning an instance back to the pool.
    returnInstance(instance) {
        // Call onDeath handler if available (and pass it current star instance)
        instance.onDeath && instance.onDeath(instance);
        // Clean up
        instance.onDeath = null;
        instance.secondColor = null;
        instance.transitionTime = 0;
        instance.colorChanged = false;
        // Add back to the pool.
        this._pool.push(instance);
    }
};


const Spark = {
    // Visual properties
    drawWidth: 0, // set in `configDidUpdate()`
    airDrag: 0.9,

    // Star particles will be keyed by color
    active: createParticleCollection(),
    _pool: [],

    _new() {
        return {};
    },

    add(x, y, color, angle, speed, life) {
        const instance = this._pool.pop() || this._new();

        instance.x = x;
        instance.y = y;
        instance.prevX = x;
        instance.prevY = y;
        instance.color = color;
        instance.speedX = Math.sin(angle) * speed;
        instance.speedY = Math.cos(angle) * speed;
        instance.life = life;

        this.active[color].push(instance);
        return instance;
    },

    // Public method for cleaning up and returning an instance back to the pool.
    returnInstance(instance) {
        // Add back to the pool.
        this._pool.push(instance);
    }
};



const soundManager = {
    baseURL: 'http://file.gs7d.cn/fire_audio/',
    ctx: new(window.AudioContext || window.webkitAudioContext),
    sources: {
        lift: {
            volume: 1,
            playbackRateMin: 0.85,
            playbackRateMax: 0.95,
            fileNames: [
                'lift1.mp3',
                'lift2.mp3',
                'lift3.mp3'
            ]
        },
        burst: {
            volume: 1,
            playbackRateMin: 0.8,
            playbackRateMax: 0.9,
            fileNames: [
                'burst1.mp3',
                'burst2.mp3'
            ]
        },
        burstSmall: {
            volume: 0.25,
            playbackRateMin: 0.8,
            playbackRateMax: 1,
            fileNames: [
                'burst-sm-1.mp3',
                'burst-sm-2.mp3'
            ]
        },
        crackle: {
            volume: 0.2,
            playbackRateMin: 1,
            playbackRateMax: 1,
            fileNames: ['crackle1.mp3']
        },
        crackleSmall: {
            volume: 0.3,
            playbackRateMin: 1,
            playbackRateMax: 1,
            fileNames: ['crackle-sm-1.mp3']
        }
    },

    preload() {
        const allFilePromises = [];

        function checkStatus(response) {
            if (response.status >= 200 && response.status < 300) {
                return response;
            }
            const customError = new Error(response.statusText);
            customError.response = response;
            throw customError;
        }

        const types = Object.keys(this.sources);
        types.forEach(type => {
            const source = this.sources[type];
            const {
                fileNames
            } = source;
            const filePromises = [];
            fileNames.forEach(fileName => {
                const fileURL = this.baseURL + fileName;
                // Promise will resolve with decoded audio buffer.
                const promise = fetch(fileURL)
                    .then(checkStatus)
                    .then(response => response.arrayBuffer())
                    .then(data => new Promise(resolve => {
                        this.ctx.decodeAudioData(data, resolve);
                    }));

                filePromises.push(promise);
                allFilePromises.push(promise);
            });

            Promise.all(filePromises)
                .then(buffers => {
                    source.buffers = buffers;
                });
        });

        return Promise.all(allFilePromises);
    },

    pauseAll() {
        this.ctx.suspend();
    },

    resumeAll() {
        // Play a sound with no volume for iOS. This 'unlocks' the audio context when the user first enables sound.
        this.playSound('lift', 0);
        // Chrome mobile requires interaction before starting audio context.
        // The sound toggle button is triggered on 'touchstart', which doesn't seem to count as a full
        // interaction to Chrome. I guess it needs a click? At any rate if the first thing the user does
        // is enable audio, it doesn't work. Using a setTimeout allows the first interaction to be registered.
        // Perhaps a better solution is to track whether the user has interacted, and if not but they try enabling
        // sound, show a tooltip that they should tap again to enable sound.
        setTimeout(() => {
            this.ctx.resume();
        }, 250);
    },

    // Private property used to throttle small burst sounds.
    _lastSmallBurstTime: 0,

    /**
     * Play a sound of `type`. Will randomly pick a file associated with type, and play it at the specified volume
     * and play speed, with a bit of random variance in play speed. This is all based on `sources` config.
     *
     * @param  {string} type - The type of sound to play.
     * @param  {?number} scale=1 - Value between 0 and 1 (values outside range will be clamped). Scales less than one
     *                             descrease volume and increase playback speed. This is because large explosions are
     *                             louder, deeper, and reverberate longer than small explosions.
     *                             Note that a scale of 0 will mute the sound.
     */
    playSound(type, scale = 1) {
        // Ensure `scale` is within valid range.
        scale = MyMath.clamp(scale, 0, 1);

        // Disallow starting new sounds if sound is disabled, app is running in slow motion, or paused.
        // Slow motion check has some wiggle room in case user doesn't finish dragging the speed bar
        // *all* the way back.
        if (!canPlaySoundSelector() || simSpeed < 0.95) {
            return;
        }

        // Throttle small bursts, since floral/falling leaves shells have a lot of them.
        if (type === 'burstSmall') {
            const now = Date.now();
            if (now - this._lastSmallBurstTime < 20) {
                return;
            }
            this._lastSmallBurstTime = now;
        }

        const source = this.sources[type];

        if (!source) {
            throw new Error(`Sound of type "${type}" doesn't exist.`);
        }

        const initialVolume = source.volume;
        const initialPlaybackRate = MyMath.random(
            source.playbackRateMin,
            source.playbackRateMax
        );

        // Volume descreases with scale.
        const scaledVolume = initialVolume * scale;
        // Playback rate increases with scale. For this, we map the scale of 0-1 to a scale of 2-1.
        // So at a scale of 1, sound plays normally, but as scale approaches 0 speed approaches double.
        const scaledPlaybackRate = initialPlaybackRate * (2 - scale);

        const gainNode = this.ctx.createGain();
        gainNode.gain.value = scaledVolume;

        const buffer = MyMath.randomChoice(source.buffers);
        const bufferSource = this.ctx.createBufferSource();
        bufferSource.playbackRate.value = scaledPlaybackRate;
        bufferSource.buffer = buffer;
        bufferSource.connect(gainNode);
        gainNode.connect(this.ctx.destination);
        bufferSource.start(0);
    }
};




// Kick things off.

function setLoadingStatus(status) {
    document.querySelector('.loading-init__status').textContent = status;
}

// CodePen profile header doesn't need audio, just initialize.
if (IS_HEADER) {
    init();
} else {
    // Allow status to render, then preload assets and start app.
    //setLoadingStatus('献给你爱的TA ❤');
    setTimeout(() => {
        soundManager.preload()
            .then(
                init,
                reason => {
                    // Codepen preview doesn't like to load the audio, so just init to fix the preview for now.
                    init();
                    // setLoadingStatus('Error Loading Audio');
                    return Promise.reject(reason);
                }
            );
    }, 0);
}